# Part of Odoo. See LICENSE file for full copyright and licensing details.

import json
from collections import defaultdict
from markupsafe import Markup

from odoo import _, api, fields, models, modules
from odoo.addons.mail.tools.discuss import Store
from odoo.exceptions import UserError, ValidationError
from odoo.tools.misc import clean_context

import logging

_logger = logging.getLogger(__name__)


class MailScheduledMessage(models.Model):
    """ Scheduled message model (holds post values generated by the composer to delay the
    posting of the message). Different from mail.message.schedule that posts the message but
    delays the notification process.

    Todo: when adding support for scheduling messages in mass_mail mode, could add a reference to
    the "parent" composer (by making 'mail.compose.message' not transient anymore). This reference
    could then be used to cancel every message scheduled "at the same time" (from one composer),
    and to get the static 'notification parameters' (mail_server_id, auto_delete,...) instead of
    duplicating them for each scheduled message.
    Currently as scheduling is allowed in monocomment only, we don't have duplicates and we only
    have static notification parameters, but some will become dynamic when adding mass_mail support
    such as 'email_from' and 'force_email_lang'.
    """
    _name = 'mail.scheduled.message'
    _description = 'Scheduled Message'

    # content
    subject = fields.Char('Subject')
    body = fields.Html('Contents', sanitize_style=True)
    scheduled_date = fields.Datetime('Scheduled Date', required=True)
    attachment_ids = fields.Many2many(
        'ir.attachment', 'scheduled_message_attachment_rel',
        'scheduled_message_id', 'attachment_id',
        string='Attachments', bypass_search_access=True)
    composition_comment_option = fields.Selection(
        [('reply_all', 'Reply-All'), ('forward', 'Forward')],
        string='Comment Options')  # mainly used for view in specific comment modes
    # related document
    model = fields.Char('Related Document Model', required=True)
    res_id = fields.Many2oneReference('Related Document Id', model_field='model', required=True)
    # origin
    author_id = fields.Many2one('res.partner', 'Author', required=True)
    # recipients
    partner_ids = fields.Many2many('res.partner', string='Recipients')
    # characteristics
    is_note = fields.Boolean('Is a note', default=False, help="If the message will be posted as a Note.")
    # notify parameters (email_from, mail_server_id, force_email_lang,...)
    notification_parameters = fields.Text('Notification parameters')
    # context used when posting the message to trigger some actions (eg. change so state when sending quotation)
    send_context = fields.Json('Sending Context')

    @api.constrains('model')
    def _check_model(self):
        if not all(model in self.pool and issubclass(self.pool[model], self.pool['mail.thread']) for model in self.mapped("model")):
            raise ValidationError(_("A message cannot be scheduled on a model that does not have a mail thread."))

    @api.constrains('scheduled_date')
    def _check_scheduled_date(self):
        if any(scheduled_message.scheduled_date < fields.Datetime().now() for scheduled_message in self):
            raise ValidationError(_("A Scheduled Message cannot be scheduled in the past"))

    # ------------------------------------------------------
    # CRUD / ORM
    # ------------------------------------------------------

    @api.model_create_multi
    def create(self, vals_list):
        # make sure user can post on the related records
        for vals in vals_list:
            self._check(vals)

        # clean context to prevent usage of default_model and default_res_id
        scheduled_messages = super(MailScheduledMessage, self.with_context(clean_context(self.env.context))).create(vals_list)
        # transfer attachments from composer to scheduled messages
        for scheduled_message in scheduled_messages:
            if attachments := scheduled_message.attachment_ids:
                attachments.filtered(
                    lambda a: a.res_model == 'mail.compose.message' and not a.res_id and a.create_uid.id == self.env.uid
                ).write({
                    'res_model': scheduled_message._name,
                    'res_id': scheduled_message.id,
                })
        # schedule cron trigger
        if scheduled_messages:
            self.env.ref('mail.ir_cron_post_scheduled_message')._trigger_list(
                set(scheduled_messages.mapped('scheduled_date'))
            )
        return scheduled_messages

    @api.model
    def _search(self, domain, offset=0, limit=None, order=None, *, bypass_access=False, **kwargs):
        """ Override that add specific access rights to only get the ids of the messages
        that are scheduled on the records on which the user has mail_post (or read) access
        """
        if self.env.is_superuser() or bypass_access:
            return super()._search(domain, offset, limit, order, bypass_access=True, **kwargs)

        # don't use the ORM to avoid cache pollution
        query = super()._search(domain, offset, limit, order, **kwargs)
        fnames_to_read = ['id', 'model', 'res_id']
        rows = self.env.execute_query(query.select(
            *[self._field_to_sql(self._table, fname) for fname in fnames_to_read],
        ))

        # group res_ids by model and determine accessible records
        model_ids = defaultdict(set)
        for __, model, res_id in rows:
            model_ids[model].add(res_id)

        allowed_ids = defaultdict(set)
        for model, res_ids in model_ids.items():
            records = self.env[model].browse(res_ids)
            operation = getattr(records, '_mail_post_access', 'write')
            if records.has_access(operation):
                allowed_ids[model] = set(records._filtered_access(operation)._ids)

        scheduled_messages = self.browse(
            msg_id
            for msg_id, res_model, res_id in rows
            if res_id in allowed_ids[res_model]
        )

        return scheduled_messages._as_query(order)

    def unlink(self):
        self._check()
        return super().unlink()

    def write(self, vals):
        # prevent changing the records on which the messages are scheduled
        if vals.get('model') or vals.get('res_id'):
            raise UserError(_('You are not allowed to change the target record of a scheduled message.'))
        # make sure user can write on the record the messages are scheduled on
        self._check()
        res = super().write(vals)
        if new_scheduled_date := vals.get('scheduled_date'):
            self.env.ref('mail.ir_cron_post_scheduled_message')._trigger(fields.Datetime.to_datetime(new_scheduled_date))
        return res

    # ------------------------------------------------------
    # Actions
    # ------------------------------------------------------

    def open_edit_form(self):
        self.ensure_one()
        return {
            'type': 'ir.actions.act_window',
            'name': _("Edit Scheduled Note") if self.is_note else _("Edit Scheduled Message"),
            'res_model': self._name,
            'view_mode': 'form',
            'views': [[False, 'form']],
            'target': 'new',
            'res_id': self.id,
        }

    def post_message(self):
        self.ensure_one()
        if self.env.is_admin() or self.create_uid.id == self.env.uid:
            self._post_message()
        else:
            raise UserError(_("You are not allowed to send this scheduled message"))

    def _message_created_hook(self, message):
        """Hook called after scheduled messages have been posted."""
        self.ensure_one()

    def _post_message(self, raise_exception=True):
        """ Post the scheduled messages.
            They are posted using their creator as user so that one can check that the creator has
            still post permission on the related record, and to allow for the attachments to be
            transferred to the messages (see _process_attachments_for_post in mail.thread)
            if raise_exception is set to False, the method will skip the posting of a message
            instead of raising an error, and send a notification to the author about the failure.
            This is useful when scheduled messages are sent from the _post_messages_cron.
        """
        notification_parameters_whitelist = self._notification_parameters_whitelist()
        auto_commit = not modules.module.current_test
        for scheduled_message in self:
            message_creator = scheduled_message.create_uid
            try:
                scheduled_message.with_user(message_creator)._check()
                message = self.env[scheduled_message.model].browse(scheduled_message.res_id).with_context(
                        clean_context(scheduled_message.send_context or {})
                    ).with_user(message_creator).message_post(
                    attachment_ids=list(scheduled_message.attachment_ids.ids),
                    author_id=scheduled_message.author_id.id,
                    subject=scheduled_message.subject,
                    body=scheduled_message.body,
                    partner_ids=list(scheduled_message.partner_ids.ids),
                    subtype_xmlid='mail.mt_note' if scheduled_message.is_note else 'mail.mt_comment',
                    **{k: v for k, v in json.loads(scheduled_message.notification_parameters or '{}').items() if k in notification_parameters_whitelist},
                )
                scheduled_message._message_created_hook(message)
                if auto_commit:
                    self.env.cr.commit()
            except Exception:
                if raise_exception:
                    raise
                _logger.info("Posting of scheduled message with ID %s failed", scheduled_message.id, exc_info=True)
                # notify user about the failure (send content as user might have lost access to the record)
                if auto_commit:
                    self.env.cr.rollback()
                try:
                    self.env['mail.thread'].message_notify(
                        partner_ids=[message_creator.partner_id.id],
                        subject=_("A scheduled message could not be sent"),
                        body=_("The message scheduled on %(model)s(%(id)s) with the following content could not be sent:%(original_message)s",
                            model=scheduled_message.model,
                            id=scheduled_message.res_id,
                            original_message=Markup("<br>-----<br>%s<br>-----<br>") % scheduled_message.body,
                        )
                    )
                    if auto_commit:
                        self.env.cr.commit()
                except Exception:
                    # in case even message_notify fails, make sure the failing scheduled message
                    # will be deleted
                    _logger.exception("The notification about the failed scheduled message could not be sent")
                    if auto_commit:
                        self.env.cr.rollback()
        self.unlink()

    # ------------------------------------------------------
    # Business Methods
    # ------------------------------------------------------

    @api.model
    def _check(self, values=None):
        """ Restrict the access to a scheduled message.
            Access is based on the record on which the scheduled message will be posted to.
            :param values: dict with model and res_id on which to perform the check
        """
        if self.env.is_superuser():
            return True

        model_ids = defaultdict(set)
        # sudo as anyways we check access on the related records
        for scheduled_message in self.sudo():
            model_ids[scheduled_message.model].add(scheduled_message.res_id)
        if values:
            model_ids[values['model']].add(values['res_id'])

        for model, res_ids in model_ids.items():
            records = self.env[model].browse(res_ids)
            operation = getattr(records, '_mail_post_access', 'write')
            records.check_access(operation)

    @api.model
    def _notification_parameters_whitelist(self):
        """ Parameters that can be used when posting the scheduled messages.
        """
        return {
            'email_add_signature',
            'email_from',
            'email_layout_xmlid',
            'force_email_lang',
            'mail_activity_type_id',
            'mail_auto_delete',
            'mail_server_id',
            'message_type',
            'model_description',
            'reply_to',
            'reply_to_force_new',
            'subtype_id',
        }

    @api.model
    def _post_messages_cron(self, limit=50):
        """ Posts past-due scheduled messages.
        """
        domain = [('scheduled_date', '<=', fields.Datetime.now())]
        messages_to_post = self.search(domain, limit=limit)
        _logger.info("Posting %s scheduled messages", len(messages_to_post))
        messages_to_post.with_context(mail_notify_force_send=True)._post_message(raise_exception=False)

        # restart cron if needed
        if self.search_count(domain, limit=1):
            self.env.ref('mail.ir_cron_post_scheduled_message')._trigger()

    def _to_store_defaults(self, target):
        return [
            Store.Many("attachment_ids"),
            Store.One("author_id"),
            "body",
            "is_note",
            "scheduled_date",
            "subject",
        ]
